Writing Advanced Queries for Business Reporting
Course: MySQL Training
Topic: Writing Advanced Queries for Business Reporting
Course Link: MySQL Training Course at JustAcademy
Demo Link: Register for a Course Demo
JustAcademy Website: Visit JustAcademy
1. Introduction to Advanced Queries for Business Reporting
Business reporting converts raw database records into meaningful information that helps organizations monitor performance, identify trends, evaluate operations, and make informed decisions. MySQL provides advanced querying capabilities that allow analysts and developers to retrieve, combine, summarize, and interpret data from multiple tables.
Basic SQL queries retrieve individual records, while advanced reporting queries answer questions such as which department generates the most revenue, which employees have the highest sales, how monthly performance changes, and which products are performing below expectations.
Advanced business reporting commonly uses aggregate functions, joins, subqueries, Common Table Expressions (CTEs), conditional expressions, date functions, window functions, views, and query optimization techniques.
1.1 Learning Objectives
- Understand the role of advanced SQL queries in business reporting.
- Retrieve information from multiple related tables using joins.
- Summarize business data using aggregate functions and grouping.
- Filter grouped results with HAVING.
- Write subqueries and Common Table Expressions.
- Use CASE expressions to create business categories.
- Generate daily, monthly, quarterly, and yearly reports.
- Calculate rankings, running totals, and moving averages with window functions.
- Build reusable reporting views and improve query performance.
- Validate reporting results before presenting them to stakeholders.
1.2 What Is a Business Reporting Query?
A business reporting query is an SQL statement designed to answer a specific business question by extracting, calculating, filtering, or summarizing data stored in a database.
| Business Question |
SQL Technique |
Expected Result |
| How much revenue was generated? |
SUM() |
Total revenue |
| How many orders were placed? |
COUNT() |
Order count |
| Which department has the highest average salary? |
JOIN, AVG(), GROUP BY |
Department salary comparison |
| Which products exceed a sales target? |
GROUP BY, HAVING |
Qualifying products |
| What is each employee's sales rank? |
RANK() or DENSE_RANK() |
Ranked employee list |
| How does monthly revenue change? |
Date functions and LAG() |
Month-to-month comparison |
2. Understanding the Database Structure for Reporting
Business reports frequently use relational databases containing multiple tables. Understanding the table relationships is essential because incorrect joins can produce duplicate records and inaccurate totals.
2.1 Example Business Database
Consider a company database containing employees, departments, customers, orders, and order items.
| Table |
Purpose |
Important Columns |
| departments |
Stores department information |
department_id, department_name |
| employees |
Stores employee details |
employee_id, employee_name, department_id, salary |
| customers |
Stores customer information |
customer_id, customer_name, city |
| orders |
Stores order-level information |
order_id, customer_id, employee_id, order_date, status |
| order_items |
Stores individual products within each order |
order_item_id, order_id, product_name, quantity, unit_price |
2.2 Understanding Primary and Foreign Keys
A primary key uniquely identifies a record in a table. A foreign key references a key in another table and helps establish relationships between records.
departments.department_id
|
+---- employees.department_id
customers.customer_id
|
+---- orders.customer_id
orders.order_id
|
+---- order_items.order_id
For example, the department ID in the employees table connects each employee to a department. This relationship allows a report to display employee names alongside department names.
2.3 Creating Example Tables
The following example uses MySQL-compatible table definitions for practice. In a real database, select data types and constraints according to business requirements.
CREATE DATABASE business_reporting;
USE business_reporting;
CREATE TABLE departments (
department_id INT PRIMARY KEY,
department_name VARCHAR(100) NOT NULL
);
CREATE TABLE employees (
employee_id INT PRIMARY KEY,
employee_name VARCHAR(100) NOT NULL,
department_id INT,
salary DECIMAL(12, 2),
hire_date DATE,
FOREIGN KEY (department_id)
REFERENCES departments(department_id)
);
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
city VARCHAR(100)
);
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT,
employee_id INT,
order_date DATE NOT NULL,
status VARCHAR(30),
FOREIGN KEY (customer_id)
REFERENCES customers(customer_id),
FOREIGN KEY (employee_id)
REFERENCES employees(employee_id)
);
CREATE TABLE order_items (
order_item_id INT PRIMARY KEY,
order_id INT NOT NULL,
product_name VARCHAR(100) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(12, 2) NOT NULL,
FOREIGN KEY (order_id)
REFERENCES orders(order_id)
);
2.4 Inserting Sample Data
INSERT INTO departments
(department_id, department_name)
VALUES
(1, 'Sales'),
(2, 'Marketing'),
(3, 'IT'),
(4, 'Finance');
INSERT INTO employees
(employee_id, employee_name, department_id, salary, hire_date)
VALUES
(101, 'Aarav Sharma', 1, 55000, '2022-04-10'),
(102, 'Priya Patel', 1, 62000, '2021-08-15'),
(103, 'Rohan Mehta', 2, 48000, '2023-01-20'),
(104, 'Neha Singh', 3, 70000, '2020-11-05'),
(105, 'Ishita Rao', 4, 60000, '2022-06-12');
INSERT INTO customers
(customer_id, customer_name, city)
VALUES
(201, 'Alpha Stores', 'Mumbai'),
(202, 'Bright Retail', 'Pune'),
(203, 'City Mart', 'Mumbai'),
(204, 'Digital World', 'Nashik');
INSERT INTO orders
(order_id, customer_id, employee_id, order_date, status)
VALUES
(1001, 201, 101, '2026-01-10', 'Completed'),
(1002, 202, 102, '2026-01-15', 'Completed'),
(1003, 203, 101, '2026-02-05', 'Completed'),
(1004, 204, 103, '2026-02-18', 'Pending'),
(1005, 201, 102, '2026-03-03', 'Completed');
INSERT INTO order_items
(order_item_id, order_id, product_name, quantity, unit_price)
VALUES
(1, 1001, 'Laptop', 2, 45000),
(2, 1001, 'Mouse', 5, 800),
(3, 1002, 'Monitor', 3, 12000),
(4, 1003, 'Laptop', 1, 45000),
(5, 1003, 'Keyboard', 4, 1500),
(6, 1004, 'Monitor', 2, 12000),
(7, 1005, 'Laptop', 1, 45000);
Important: These are illustrative records. In the examples below, order revenue is calculated as quantity multiplied by unit price. For financial reporting, also define how discounts, taxes, returns, shipping charges, cancellations, and payment status affect revenue.
3. Reviewing the SELECT Statement for Reporting
The SELECT statement retrieves data from database tables. Reporting queries often include several clauses that control the selected columns, data sources, filters, grouping, and sorting.
3.1 General Query Structure
SELECT column_name, aggregate_function(column_name)
FROM table_name
JOIN another_table
ON table_name.id = another_table.id
WHERE row_condition
GROUP BY column_name
HAVING group_condition
ORDER BY column_name DESC
LIMIT 10;
3.2 Example: Retrieve Completed Orders
SELECT
order_id,
customer_id,
order_date,
status
FROM orders
WHERE status = 'Completed'
ORDER BY order_date DESC;
This query returns completed orders and displays the newest order first. The WHERE clause filters individual rows before grouping occurs.
3.3 Choosing Relevant Columns
Business reports should include the columns required to answer the question, rather than returning every column using SELECT *. Selecting only necessary columns improves readability and may reduce data transfer.
4. Aggregate Functions for Business Analysis
Aggregate functions calculate a single result from multiple rows. They are essential for summaries such as total revenue, average salary, number of orders, and highest transaction value.
| Function |
Purpose |
Example |
| SUM() |
Adds numeric values |
SUM(quantity * unit_price) |
| COUNT() |
Counts rows or non-NULL values |
COUNT(order_id) |
| COUNT(DISTINCT ...) |
Counts unique values |
COUNT(DISTINCT customer_id) |
| AVG() |
Calculates an average |
AVG(salary) |
| MIN() |
Finds the minimum value |
MIN(salary) |
| MAX() |
Finds the maximum value |
MAX(salary) |
4.1 Calculate Total Order Revenue
SELECT
SUM(oi.quantity * oi.unit_price) AS total_order_value
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed';
The expression quantity multiplied by unit_price calculates the value of each order item. SUM() adds these values together for completed orders.
4.2 Count Total Orders
SELECT
COUNT(*) AS total_orders,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
WHERE status = 'Completed';
COUNT(*) counts qualifying order records, while COUNT(DISTINCT customer_id) counts unique customers with qualifying orders.
4.3 Calculate Average, Minimum, and Maximum Salary
SELECT
AVG(salary) AS average_salary,
MIN(salary) AS minimum_salary,
MAX(salary) AS maximum_salary
FROM employees;
These calculations help the HR or finance team understand the salary distribution. Aggregate functions such as AVG(), MIN(), and MAX() generally ignore NULL values, so missing data should be assessed before interpreting the results.
5. GROUP BY for Department and Product Reports
GROUP BY divides rows into groups based on one or more columns. Aggregate functions then calculate a summary for each group.
5.1 Revenue by Product
SELECT
oi.product_name,
SUM(oi.quantity) AS total_units_sold,
SUM(oi.quantity * oi.unit_price) AS product_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
ORDER BY product_revenue DESC;
This report groups order items by product and calculates units sold and revenue. The highest-revenue products appear first.
5.2 Employee Count by Department
SELECT
d.department_name,
COUNT(e.employee_id) AS employee_count
FROM departments AS d
LEFT JOIN employees AS e
ON d.department_id = e.department_id
GROUP BY
d.department_id,
d.department_name
ORDER BY employee_count DESC;
LEFT JOIN retains departments even when no employees are assigned to them. COUNT(e.employee_id) returns zero for a department with no matching employee, unlike COUNT(*), which would count the preserved department row.
5.3 Average Salary by Department
SELECT
d.department_name,
COUNT(e.employee_id) AS employee_count,
ROUND(AVG(e.salary), 2) AS average_salary,
SUM(e.salary) AS total_salary
FROM departments AS d
LEFT JOIN employees AS e
ON d.department_id = e.department_id
GROUP BY
d.department_id,
d.department_name
ORDER BY average_salary DESC;
This report combines headcount, average salary, and total salary expense in one result. If a department has no employees or all salary values are NULL, the average salary may be NULL rather than zero.
6. Filtering Groups with HAVING
WHERE filters individual records before aggregation. HAVING filters the groups produced by GROUP BY.
6.1 Products with Revenue Above a Threshold
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
HAVING SUM(oi.quantity * oi.unit_price) > 50000
ORDER BY total_revenue DESC;
This query returns only products whose completed-order revenue exceeds 50,000.
6.2 Departments with More Than One Employee
SELECT
d.department_name,
COUNT(e.employee_id) AS employee_count
FROM departments AS d
LEFT JOIN employees AS e
ON d.department_id = e.department_id
GROUP BY
d.department_id,
d.department_name
HAVING COUNT(e.employee_id) > 1;
6.3 WHERE Versus HAVING
| Feature |
WHERE |
HAVING |
| Filters |
Individual rows |
Groups or aggregate results |
| Typical position |
Before GROUP BY |
After GROUP BY |
| Example |
WHERE status = 'Completed' |
HAVING SUM(amount) > 50000 |
| Common use |
Date, status, region, category filters |
Revenue, count, average, or other group thresholds |
7. Advanced Joins for Combined Business Reports
Joins allow a report to combine information from related tables. Choosing the correct join type and understanding each table's level of detail are critical to accurate reporting.
7.1 INNER JOIN
INNER JOIN returns rows that match the join condition in both tables.
SELECT
o.order_id,
c.customer_name,
o.order_date,
o.status
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
ORDER BY o.order_date;
7.2 LEFT JOIN
LEFT JOIN preserves all records from the left table and includes matching records from the right table. Missing matches appear as NULL.
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS total_orders
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY total_orders DESC;
This report includes customers who have never placed an order. COUNT(o.order_id) returns zero when there is no matching order.
7.3 Join Three Tables for Sales Reporting
SELECT
o.order_id,
c.customer_name,
e.employee_name AS sales_employee,
SUM(oi.quantity * oi.unit_price) AS order_value
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.customer_id
JOIN employees AS e
ON o.employee_id = e.employee_id
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY
o.order_id,
c.customer_name,
e.employee_name
ORDER BY order_value DESC;
This report connects orders to customers, employees, and order items. It calculates the total line-item value for each order.
7.4 Avoiding Duplicate Totals
Joining tables with different levels of detail can multiply rows. For example, one order may have several order items. If the order table also contains an order-level amount, summing that amount after joining to order items can count it multiple times.
To prevent inflated totals:
- Identify the grain of each table: order, order item, customer, or employee.
- Aggregate data at the appropriate level before combining independent one-to-many relationships.
- Use COUNT(DISTINCT order_id) when the business metric requires unique orders.
- Do not use DISTINCT merely to hide an incorrect join.
- Validate sample orders manually against the source data.
8. Subqueries for Advanced Reporting
A subquery is a query nested inside another SQL statement. Subqueries can compare records against summary values or identify records that satisfy a business condition.
8.1 Employees Earning Above the Company Average
SELECT
employee_id,
employee_name,
salary
FROM employees
WHERE salary > (
SELECT AVG(salary)
FROM employees
)
ORDER BY salary DESC;
The inner query calculates the average salary. The outer query returns employees whose salaries exceed that value.
8.2 Products with Revenue Above Average Product Revenue
SELECT
product_name,
product_revenue
FROM (
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS product_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
) AS product_summary
WHERE product_revenue > (
SELECT AVG(product_revenue)
FROM (
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS product_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
) AS average_source
);
This example first calculates revenue by product, then compares each product's revenue with the average of the product-level revenue values. The derived tables use aliases, as required by MySQL.
8.3 EXISTS for Customers with Completed Orders
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'Completed'
);
EXISTS tests whether at least one matching row exists. It is useful when the report needs to identify qualifying customers without joining every order into the output.
9. Common Table Expressions (CTEs)
A Common Table Expression is a named query result introduced with WITH. CTEs can make complicated reports easier to read by separating the report into logical steps. CTEs are supported in MySQL 8.0 and later.
9.1 Basic CTE Example
WITH department_summary AS (
SELECT
department_id,
COUNT(*) AS employee_count,
AVG(salary) AS average_salary
FROM employees
GROUP BY department_id
)
SELECT
d.department_name,
ds.employee_count,
ROUND(ds.average_salary, 2) AS average_salary
FROM department_summary AS ds
JOIN departments AS d
ON ds.department_id = d.department_id
ORDER BY ds.average_salary DESC;
The CTE calculates employee statistics once in a named query block. The outer query adds department names and sorts the results.
9.2 Multiple CTEs for a Sales Report
WITH order_totals AS (
SELECT
o.order_id,
o.customer_id,
o.order_date,
SUM(oi.quantity * oi.unit_price) AS order_value
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY
o.order_id,
o.customer_id,
o.order_date
),
customer_totals AS (
SELECT
customer_id,
COUNT(*) AS completed_orders,
SUM(order_value) AS total_spend
FROM order_totals
GROUP BY customer_id
)
SELECT
c.customer_name,
ct.completed_orders,
ct.total_spend
FROM customer_totals AS ct
JOIN customers AS c
ON ct.customer_id = c.customer_id
ORDER BY ct.total_spend DESC;
The first CTE calculates each completed order's value. The second summarizes order counts and spending by customer. The final query adds customer names.
9.3 Benefits and Limitations of CTEs
- Improve readability by breaking complex logic into named stages.
- Make it easier to test intermediate calculations.
- Help organize reporting logic into meaningful steps.
- Do not automatically make a query faster; performance depends on the execution plan and data.
- Should be tested with realistic data volumes before use in production reports.
10. CASE Expressions for Business Categories
CASE creates conditional output based on specified conditions. It is useful for assigning performance categories, grouping salary levels, classifying orders, and calculating conditional metrics.
10.1 Categorize Employee Salaries
SELECT
employee_name,
salary,
CASE
WHEN salary >= 65000 THEN 'High Salary'
WHEN salary >= 50000 THEN 'Medium Salary'
ELSE 'Entry-Level Salary'
END AS salary_category
FROM employees
ORDER BY salary DESC;
The CASE expression evaluates conditions from top to bottom and returns the result for the first matching condition.
10.2 Categorize Order Values
WITH order_totals AS (
SELECT
o.order_id,
SUM(oi.quantity * oi.unit_price) AS order_value
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY o.order_id
)
SELECT
order_id,
order_value,
CASE
WHEN order_value >= 100000 THEN 'Large Order'
WHEN order_value >= 50000 THEN 'Medium Order'
ELSE 'Small Order'
END AS order_category
FROM order_totals
ORDER BY order_value DESC;
10.3 Conditional Aggregation
Conditional aggregation allows a single query to calculate several business metrics using different conditions.
SELECT
COUNT(*) AS total_orders,
SUM(CASE
WHEN status = 'Completed' THEN 1
ELSE 0
END) AS completed_orders,
SUM(CASE
WHEN status = 'Pending' THEN 1
ELSE 0
END) AS pending_orders
FROM orders;
This query creates a compact order-status summary. If a status can be NULL or additional status values exist, define how those records should be handled in the report.
11. Date-Based Reporting
Business reports often compare performance over days, weeks, months, quarters, and years. MySQL provides date functions that help group and filter records according to business periods.
11.1 Revenue by Month
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS reporting_month,
SUM(oi.quantity * oi.unit_price) AS monthly_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
ORDER BY reporting_month;
DATE_FORMAT() converts each order date into a year-month label. Including the year prevents January from different years from being grouped together.
11.2 Revenue by Year
SELECT
YEAR(o.order_date) AS reporting_year,
SUM(oi.quantity * oi.unit_price) AS annual_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY YEAR(o.order_date)
ORDER BY reporting_year;
11.3 Filter a Date Range
SELECT
order_id,
customer_id,
order_date
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2026-04-01'
ORDER BY order_date;
This query selects orders from January through March 2026. The half-open interval includes the start date and excludes the next period's start. It is also useful for DATETIME columns because it includes every time on the final intended day.
11.4 Current-Period Reports
SELECT
COUNT(*) AS orders_this_month
FROM orders
WHERE order_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01')
AND order_date < DATE_ADD(
DATE_FORMAT(CURDATE(), '%Y-%m-01'),
INTERVAL 1 MONTH
);
This query counts orders from the first day of the current month up to, but not including, the first day of the next month. CURDATE() uses the database session's date context, so verify the server and session time-zone settings when reports depend on exact time boundaries.
12. Window Functions for Business Intelligence
Window functions calculate values across a related set of rows while preserving the individual rows in the result. They are useful for ranking, running totals, period comparisons, and moving averages.
Window functions are available in MySQL 8.0 and later.
12.1 Rank Employees by Salary
SELECT
employee_id,
employee_name,
salary,
RANK() OVER (
ORDER BY salary DESC
) AS salary_rank
FROM employees
ORDER BY salary DESC;
RANK() assigns the same rank to tied salaries and leaves gaps after ties. For example, two employees ranked first can be followed by rank three.
12.2 Compare RANK(), DENSE_RANK(), and ROW_NUMBER()
| Function |
Behavior |
Common Use |
| RANK() |
Ties share a rank; subsequent ranks can have gaps |
Competition-style ranking |
| DENSE_RANK() |
Ties share a rank; subsequent ranks have no gaps |
Distinct performance levels |
| ROW_NUMBER() |
Assigns a unique sequential number to each row |
Top-N records and ordered row numbering |
12.3 Rank Products by Revenue
WITH product_sales AS (
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
)
SELECT
product_name,
total_revenue,
DENSE_RANK() OVER (
ORDER BY total_revenue DESC
) AS revenue_rank
FROM product_sales
ORDER BY revenue_rank, product_name;
12.4 Running Revenue Total
WITH monthly_sales AS (
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS reporting_month,
SUM(oi.quantity * oi.unit_price) AS monthly_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
)
SELECT
reporting_month,
monthly_revenue,
SUM(monthly_revenue) OVER (
ORDER BY reporting_month
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_revenue
FROM monthly_sales
ORDER BY reporting_month;
The running total adds each month's revenue to the revenue from previous months. Only months present in the source data appear. To show months with no sales, join the report to a calendar table or generated date series.
12.5 Compare Revenue with the Previous Month
WITH monthly_sales AS (
SELECT
DATE_FORMAT(o.order_date, '%Y-%m') AS reporting_month,
SUM(oi.quantity * oi.unit_price) AS monthly_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY DATE_FORMAT(o.order_date, '%Y-%m')
),
monthly_comparison AS (
SELECT
reporting_month,
monthly_revenue,
LAG(monthly_revenue) OVER (
ORDER BY reporting_month
) AS previous_revenue
FROM monthly_sales
)
SELECT
reporting_month,
monthly_revenue,
previous_revenue,
monthly_revenue - previous_revenue AS revenue_change
FROM monthly_comparison
ORDER BY reporting_month;
LAG() retrieves a value from a preceding row. When months with no sales are missing, the previous row may not represent the immediately preceding calendar month. A calendar table is needed when strict month-to-month comparison is required.
13. Calculating Business KPIs
Key Performance Indicators (KPIs) are measurable values used to evaluate business performance. SQL queries can calculate KPIs directly from operational data.
| KPI |
Meaning |
Example Calculation |
| Total Revenue |
Total value of qualifying sales |
SUM(quantity * unit_price) |
| Total Orders |
Number of qualifying orders |
COUNT(DISTINCT order_id) |
| Average Order Value |
Average value per qualifying order |
Total revenue / qualifying orders |
| Units Sold |
Total quantity sold |
SUM(quantity) |
| Customer Count |
Unique customers with qualifying activity |
COUNT(DISTINCT customer_id) |
| Revenue per Employee |
Revenue divided by a defined employee population |
Total revenue / eligible employees |
13.1 Calculate Total Revenue and Average Order Value
WITH order_totals AS (
SELECT
o.order_id,
SUM(oi.quantity * oi.unit_price) AS order_value
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY o.order_id
)
SELECT
COUNT(*) AS completed_orders,
SUM(order_value) AS total_revenue,
ROUND(AVG(order_value), 2) AS average_order_value
FROM order_totals;
Calculating order-level values before calculating the average ensures that each order contributes once to the average order value, regardless of how many line items it contains.
13.2 Calculate Revenue by Sales Employee
SELECT
e.employee_id,
e.employee_name,
COUNT(DISTINCT o.order_id) AS completed_orders,
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM employees AS e
JOIN orders AS o
ON e.employee_id = o.employee_id
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY e.employee_id, e.employee_name
ORDER BY total_revenue DESC;
This report attributes completed-order revenue to the employee recorded on each order. If orders can be shared among multiple salespeople, the business must define an allocation rule before using this report for commissions or employee performance.
13.3 Calculate Revenue Contribution Percentage
WITH product_sales AS (
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS product_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
)
SELECT
product_name,
product_revenue,
ROUND(
100.0 * product_revenue /
NULLIF(SUM(product_revenue) OVER (), 0),
2
) AS revenue_percentage
FROM product_sales
ORDER BY product_revenue DESC;
The window SUM() calculates total revenue across the product rows. NULLIF() prevents division by zero. The percentage represents each product's contribution to the total revenue included in the report.
14. UNION and UNION ALL for Combining Reports
UNION combines result sets from multiple SELECT statements. Each SELECT must return the same number of columns with compatible data types.
14.1 Combine Customer Lists
SELECT customer_name AS name, city
FROM customers
WHERE city = 'Mumbai'
UNION
SELECT customer_name AS name, city
FROM customers
WHERE city = 'Pune';
UNION removes duplicate rows. UNION ALL retains duplicate rows and can be faster when duplicate elimination is unnecessary.
14.2 Compare UNION and UNION ALL
| Feature |
UNION |
UNION ALL |
| Combines results |
Yes |
Yes |
| Removes duplicate rows |
Yes |
No |
| Typical performance |
May require extra work to remove duplicates |
Often less work when duplicates are acceptable |
| Common use |
Distinct combined lists |
Appending compatible datasets while preserving every row |
15. Creating Views for Reusable Reports
A view is a named SQL query that can be used like a table in subsequent queries. Views help standardize reporting logic and reduce the need to rewrite the same joins and calculations.
15.1 Create a Completed-Order Revenue View
CREATE VIEW completed_order_revenue AS
SELECT
o.order_id,
o.customer_id,
o.employee_id,
o.order_date,
SUM(oi.quantity * oi.unit_price) AS order_value
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY
o.order_id,
o.customer_id,
o.employee_id,
o.order_date;
15.2 Query the View
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS reporting_month,
COUNT(*) AS completed_orders,
SUM(order_value) AS total_revenue
FROM completed_order_revenue
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY reporting_month;
This query reuses the view to summarize completed orders by month. A normal MySQL view stores the query definition, not a permanently refreshed copy of the results. Its execution cost depends on the underlying query and database engine.
15.3 Benefits of Views
- Encourage consistent calculations across reports.
- Provide a simpler interface for report authors.
- Can help limit access to selected columns when combined with appropriate permissions.
- Make complex SQL logic easier to reuse.
- Still require performance testing and correct access-control configuration.
16. Query Optimization for Reporting
Business reports may process thousands or millions of rows. Optimizing queries helps reduce execution time and resource usage, especially for dashboards that refresh frequently.
16.1 Use Appropriate Indexes
Indexes can speed up filtering and joins when their structure matches the query patterns. Index design should consider the columns used in WHERE, JOIN, and ORDER BY clauses, as well as the selectivity of those columns.
CREATE INDEX idx_orders_status_date
ON orders(status, order_date);
CREATE INDEX idx_orders_customer
ON orders(customer_id);
CREATE INDEX idx_order_items_order
ON order_items(order_id);
These are example indexes, not a universal recommendation. Primary keys and foreign-key constraints may already require or create relevant indexes depending on the table definition and storage engine. Review existing indexes before adding duplicates.
16.2 Avoid Unnecessary SELECT *
-- Less targeted
SELECT *
FROM orders;
-- More targeted
SELECT
order_id,
customer_id,
order_date,
status
FROM orders;
Selecting only the columns needed for a report improves clarity and may reduce disk reads, memory use, and network transfer.
16.3 Avoid Functions on Filtered Date Columns When Possible
-- Often less index-friendly
SELECT order_id
FROM orders
WHERE YEAR(order_date) = 2026;
-- Range-based alternative
SELECT order_id
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01';
The range-based condition can make better use of a conventional index on order_date. Always verify the actual execution plan because performance depends on indexes, table size, data distribution, and the MySQL version.
16.4 Use EXPLAIN
EXPLAIN
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE status = 'Completed'
GROUP BY customer_id;
EXPLAIN shows the optimizer's planned approach, including table access methods, possible indexes, chosen indexes, and estimated rows. In supported MySQL versions, EXPLAIN ANALYZE executes the query and reports actual execution details. Use it carefully on expensive production queries.
16.5 Additional Optimization Practices
- Filter records early when the filtering condition does not change the required report semantics.
- Use appropriate numeric and date data types.
- Keep statistics current so the optimizer can make informed choices.
- Use indexes based on measured query patterns rather than indexing every column.
- Review join conditions to ensure they use the intended keys.
- Aggregate at the required reporting level to prevent unnecessary row expansion.
- Use pagination or date filters for very large detail reports.
- Benchmark representative queries using realistic data volumes.
17. Handling NULL Values and Data Quality
NULL represents a missing or unknown value. It is not equivalent to zero, an empty string, or the text 'NULL'. In business reporting, incorrect handling of NULL values can distort totals and averages.
17.1 Identify Missing Values
SELECT
employee_id,
employee_name
FROM employees
WHERE salary IS NULL;
Use IS NULL or IS NOT NULL to test missing values. Comparisons such as salary = NULL do not work as expected in SQL.
17.2 Use COALESCE Carefully
SELECT
employee_name,
COALESCE(salary, 0) AS displayed_salary
FROM employees;
COALESCE returns the first non-NULL value. Replacing missing salary with zero may be appropriate for a display requirement, but it can misrepresent salary statistics. Use this substitution only when the business definition supports it.
17.3 Validate Reporting Totals
SELECT
COUNT(*) AS item_rows,
SUM(quantity) AS total_units,
SUM(quantity * unit_price) AS total_item_value,
SUM(CASE
WHEN quantity <= 0 THEN 1
ELSE 0
END) AS invalid_quantity_rows,
SUM(CASE
WHEN unit_price < 0 THEN 1
ELSE 0
END) AS negative_price_rows
FROM order_items;
This validation query checks basic quantity and price conditions. Actual business rules may allow negative values for returns, credits, or adjustments, so investigate exceptions instead of automatically deleting them.
18. Security and Safe Query Practices
Reporting queries can expose customer details, salary information, and financial performance. Accuracy must be combined with appropriate security and data-handling practices.
- Grant users access only to the tables, views, and columns needed for their role.
- Avoid exposing personal or confidential information in general-purpose reports.
- Use parameterized queries in applications instead of building SQL by concatenating user input.
- Validate filters supplied by report users.
- Do not grant administrative privileges merely to allow read-only reporting.
- Test complex queries on a development or staging environment before production use.
- Apply the organization's retention, privacy, and audit requirements to exported reports.
19. Common Errors in Advanced Reporting Queries
| Problem |
Possible Cause |
Recommended Solution |
| Revenue is too high |
One-to-many joins duplicate order-level values |
Check table grain and aggregate at the correct level |
| Customers are missing |
INNER JOIN removes customers without matching orders |
Use LEFT JOIN when all customers must appear |
| Incorrect group results |
Missing or incorrect GROUP BY columns |
Review grouping level and aggregate definitions |
| Aggregate filter error |
Aggregate condition placed in WHERE |
Use HAVING for group-level conditions |
| Incorrect monthly comparison |
Months with no activity are absent |
Use a calendar table or complete date series |
| Division by zero |
Denominator is zero |
Use NULLIF() and define how undefined ratios are reported |
| Slow report execution |
Large scans, missing useful indexes, or expensive joins |
Inspect EXPLAIN and benchmark improvements |
| Incorrect average order value |
Average calculated over line items instead of orders |
Calculate order totals first, then average the order values |
| Duplicate customers in a summary |
Customer-level data joined to multiple detail records |
Aggregate the detail records before joining when needed |
| Unexpected NULL results |
Missing source values or unmatched joins |
Inspect NULL handling and source-data quality |
20. Step-by-Step Business Reporting Workflow
A reliable report begins with a clear business question and ends with a validated result that stakeholders can interpret correctly.
- Define the objective: Identify the decision the report must support, such as tracking monthly revenue or department staffing.
- Identify the metric: Define revenue, profit, active customer, completed order, or another measure precisely.
- Identify the data sources: Find the tables, primary keys, foreign keys, and relevant columns.
- Define the reporting grain: Decide whether one row represents an order, product, customer, employee, or month.
- Write a basic SELECT: Retrieve the necessary records and inspect representative data.
- Apply filters: Restrict the report by status, date, region, or other valid criteria.
- Add joins: Combine related tables using the correct keys.
- Calculate metrics: Use aggregate functions and conditional expressions.
- Group and sort: Apply GROUP BY, HAVING, and ORDER BY as required.
- Add advanced analysis: Use CTEs, subqueries, or window functions for rankings and comparisons.
- Validate the results: Compare sample records and totals with source data or trusted reports.
- Optimize the query: Review the execution plan and test useful indexes.
- Document the report: Explain the metric definitions, filters, exclusions, and refresh schedule.
21. Practical Project: Monthly Sales Performance Report
Project Objective: Build a monthly report that summarizes completed orders, total revenue, units sold, and average order value. The report will help management understand sales performance across reporting periods.
21.1 Business Requirements
- Include completed orders only.
- Group results by calendar month.
- Calculate total revenue using quantity multiplied by unit price.
- Count unique orders rather than order-item rows.
- Calculate average order value from order-level totals.
- Display months in chronological order.
- Keep the reporting period clear and reproducible.
21.2 Build the Report
WITH order_totals AS (
SELECT
o.order_id,
o.order_date,
SUM(oi.quantity * oi.unit_price) AS order_value,
SUM(oi.quantity) AS units_sold
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
AND o.order_date >= '2026-01-01'
AND o.order_date < '2027-01-01'
GROUP BY
o.order_id,
o.order_date
),
monthly_report AS (
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS reporting_month,
COUNT(*) AS completed_orders,
SUM(units_sold) AS units_sold,
SUM(order_value) AS total_revenue,
AVG(order_value) AS average_order_value
FROM order_totals
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
)
SELECT
reporting_month,
completed_orders,
units_sold,
total_revenue,
ROUND(average_order_value, 2) AS average_order_value
FROM monthly_report
ORDER BY reporting_month;
21.3 Explanation of the Project Query
- order_totals CTE: Calculates one row per qualifying order and totals its line items.
- Date filter: Limits the report to the 2026 calendar year.
- monthly_report CTE: Groups the order-level data by month.
- COUNT(*): Counts orders because the first CTE contains one row per order.
- SUM(units_sold): Adds the units recorded in completed orders.
- SUM(order_value): Calculates total order-item value for each month.
- AVG(order_value): Calculates the average value per order rather than per item.
21.4 Project Validation Checklist
- Confirm that only orders with the intended completed status are included.
- Verify that order IDs are unique in the order_totals result.
- Manually calculate the value of at least two sample orders.
- Compare the report's total revenue with an independently calculated total.
- Confirm that the date range includes the intended start and end dates.
- Document whether discounts, taxes, shipping, returns, and refunds are included.
- Check whether months without completed orders should appear as zero or be omitted.
- Test the report with larger datasets and inspect its execution plan.
22. Practice Exercises
Complete these exercises using the example business_reporting database.
- Write a query that displays all employees with their department names.
- Calculate the average salary for every department.
- Display customers who have no orders.
- Find the number of completed orders placed by each customer.
- Calculate total revenue for each product.
- Display products whose total revenue exceeds 50,000.
- Find employees whose salaries exceed the company-wide average.
- Rank products by revenue using DENSE_RANK().
- Calculate total completed-order revenue by month.
- Calculate the average order value for each month.
- Build a report showing completed, pending, and other orders by status.
- Use a CTE to calculate order-level totals and then summarize them by customer.
- Create a view for completed-order revenue and query the view by month.
- Use EXPLAIN to inspect a query that filters orders by status and date.
- Identify a potential duplicate-counting problem caused by joining order-level and item-level data.
22.1 Sample Solutions
Exercise 1: Employees and Departments
SELECT
e.employee_id,
e.employee_name,
d.department_name,
e.salary
FROM employees AS e
LEFT JOIN departments AS d
ON e.department_id = d.department_id
ORDER BY d.department_name, e.employee_name;
Exercise 2: Average Salary by Department
SELECT
d.department_name,
ROUND(AVG(e.salary), 2) AS average_salary
FROM departments AS d
LEFT JOIN employees AS e
ON d.department_id = e.department_id
GROUP BY d.department_id, d.department_name
ORDER BY average_salary DESC;
Exercise 3: Customers Without Orders
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
Exercise 4: Completed Orders per Customer
SELECT
c.customer_name,
COUNT(o.order_id) AS completed_orders
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
AND o.status = 'Completed'
GROUP BY c.customer_id, c.customer_name
ORDER BY completed_orders DESC;
Exercise 5: Total Revenue by Product
SELECT
oi.product_name,
SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM orders AS o
JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.status = 'Completed'
GROUP BY oi.product_name
ORDER BY total_revenue DESC;
23. Business Reporting Best Practices
- Use clear aliases such as total_revenue, average_order_value, and completed_orders.
- Define the business meaning of every metric before writing the query.
- Use consistent date ranges and time-zone assumptions across reports.
- Distinguish order counts from order-item counts and unique customers from customer transactions.
- Do not mix incompatible levels of detail in aggregate calculations.
- Use NULL handling that reflects business meaning instead of automatically converting all missing values to zero.
- Validate report totals against known source records and trusted calculations.
- Keep SQL readable by formatting clauses and using meaningful table aliases.
- Use CTEs and views to organize reusable reporting logic.
- Test query performance on realistic data and review the execution plan.
- Document filters, assumptions, exclusions, and metric definitions.
- Protect confidential information through appropriate permissions and data minimization.
24. Interview Questions and Answers
Q1. What is business reporting in MySQL?
Answer: Business reporting uses SQL queries to convert database records into summaries, metrics, and comparisons that support operational and strategic decisions.
Q2. What is the difference between WHERE and HAVING?
Answer: WHERE filters individual rows before aggregation. HAVING filters grouped results, commonly using aggregate expressions such as SUM(), COUNT(), or AVG().
Q3. Why is GROUP BY used?
Answer: GROUP BY combines rows with matching values into groups so aggregate functions can calculate summaries for each group.
Q4. What is a subquery?
Answer: A subquery is a SELECT statement nested within another SQL statement. It can calculate comparison values, filter records, or produce a derived result set.
Q5. What is a CTE?
Answer: A Common Table Expression is a named query block defined with WITH. It improves query organization and helps divide complex reporting logic into understandable stages.
Q6. What is the difference between UNION and UNION ALL?
Answer: UNION removes duplicate result rows, while UNION ALL retains them. UNION ALL is often preferable when duplicates are valid and do not need to be removed.
Q7. What are window functions?
Answer: Window functions calculate values across related rows while retaining individual result rows. Examples include RANK(), ROW_NUMBER(), LAG(), and SUM() OVER().
Q8. What is the difference between RANK() and DENSE_RANK()?
Answer: Both assign equal ranks to tied values. RANK() leaves gaps after ties, while DENSE_RANK() assigns consecutive ranks without gaps.
Q9. What is a view in MySQL?
Answer: A view is a named SQL query that can be queried like a table. It can standardize calculations and simplify access to commonly used reporting logic.
Q10. Why can a report show incorrect revenue after joining tables?
Answer: A one-to-many join can duplicate values from the table on the one side. The solution is to understand each table's grain and aggregate at the correct level before combining data.
Q11. How do you calculate average order value?
Answer: First calculate the total value of each qualifying order, then average those order totals. Averaging individual line-item values would calculate a different metric.
Q12. How can a query's performance be investigated?
Answer: Use EXPLAIN to inspect the execution plan, review table scans and index choices, verify join conditions, and test appropriate indexes with representative data.
Q13. How do you calculate month-over-month revenue change?
Answer: Aggregate revenue by month, use LAG() to retrieve the previous row's revenue, and subtract the previous value from the current value. A calendar table is needed when months without activity must be included.
Q14. Why should COUNT(DISTINCT order_id) sometimes be used?
Answer: A join to order items may produce several rows for one order. COUNT(DISTINCT order_id) counts each order once, provided that unique-order counting is the intended metric.
Q15. What should be validated before publishing a business report?
Answer: Validate filters, joins, table grain, date boundaries, metric definitions, NULL handling, duplicate counts, totals, permissions, and query performance.
25. Quick Revision Checklist
- SELECT retrieves the required data.
- WHERE filters individual records.
- JOIN combines related tables.
- GROUP BY creates reporting groups.
- SUM(), COUNT(), AVG(), MIN(), and MAX() calculate summary values.
- HAVING filters aggregate results.
- Subqueries allow nested calculations and filtering.
- CTEs organize complex queries into logical stages.
- CASE creates conditional business categories.
- Date functions support daily, monthly, quarterly, and yearly analysis.
- Window functions support ranking, running totals, and comparisons.
- Views provide reusable query definitions.
- Indexes and EXPLAIN help investigate query performance.
- Correct table grain and join logic prevent inflated totals.
- Validation ensures that business reports are accurate and meaningful.
26. Conclusion
Writing advanced queries for business reporting is an important MySQL skill for developers, database professionals, business analysts, and reporting specialists. By combining joins, aggregate functions, grouping, subqueries, CTEs, conditional expressions, date functions, and window functions, it is possible to transform operational data into useful reports.
Reliable reporting requires more than writing syntactically correct SQL. The query must use the right business definitions, preserve the intended level of detail, handle missing values appropriately, and produce totals that can be independently validated. Performance testing, access controls, and clear documentation are also essential for production reporting.
Continue learning and practicing through the JustAcademy MySQL Training Course. To learn more about course options, use the JustAcademy Course Demo Registration page or visit the JustAcademy website.